Przeglądaj źródła

Merge pull request #1190 from nightscout/fix/watch-state-sync

Fix watch app back-sync after long staleness
Deniz Cengiz 10 godzin temu
rodzic
commit
4d0cc58862

+ 41 - 55
Trio Watch App Extension/WatchState.swift

@@ -62,6 +62,11 @@ import WatchConnectivity
 
     var recommendedBolus: Decimal = 0
 
+    /// Snapshots older than this are dropped at the top of the WC delegate
+    /// methods. Single source of truth for both `didReceiveMessage` and
+    /// `didReceiveUserInfo`.
+    private static let maxAcceptableMessageAgeInMinutes: TimeInterval = 15 * 60
+
     // MARK: - Debouncing and batch processing helpers
 
     /// Temporary storage for new data arriving via WatchConnectivity.
@@ -180,92 +185,73 @@ import WatchConnectivity
             await WatchLogger.shared.log("⌚️ Watch received data: \(message)")
         }
 
-        // If the message has a nested "watchState" dictionary with date as TimeInterval
-        if let watchStateDict = message[WatchMessageKeys.watchState] as? [String: Any],
-           let timestamp = watchStateDict[WatchMessageKeys.date] as? TimeInterval
-        {
-            let date = Date(timeIntervalSince1970: timestamp)
-
-            // Check if it's not older than 15 min
-            if date >= Date().addingTimeInterval(-15 * 60) {
-                Task {
-                    await WatchLogger.shared.log("⌚️ Handling watchState from \(date)")
-                }
-                processWatchMessage(message)
-            } else {
-                Task {
-                    await WatchLogger.shared.log("⌚️ Received outdated watchState data (\(date))")
-                }
-                DispatchQueue.main.async {
-                    self.showSyncingAnimation = false
-                }
-            }
-            return
-        }
-
-        // Else if the message is an "ack" at the top level
-        // e.g. { "acknowledged": true, "message": "Started Temp Target...", "date": Date(...) }
-        else if
-            let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
-            let ackMessage = message[WatchMessageKeys.message] as? String,
-            let ackCodeRaw = message[WatchMessageKeys.ackCode] as? String
+        // Ack at top level — no `watchState` wrapper, no staleness check.
+        if let acknowledged = message[WatchMessageKeys.acknowledged] as? Bool,
+           let ackMessage = message[WatchMessageKeys.message] as? String,
+           let ackCodeRaw = message[WatchMessageKeys.ackCode] as? String
         {
             Task {
                 await WatchLogger.shared
                     .log("⌚️ Handling ack with message: \(ackMessage), success: \(acknowledged), ackCode: \(ackCodeRaw)")
             }
             DispatchQueue.main.async {
-                // For ack messages, we do NOT show “Syncing...”
                 self.showSyncingAnimation = false
             }
             processWatchMessage(message)
             return
+        }
 
-                    // Recommended bolus is also not part of the WatchState message, hence the extra condition here
-        } else if
-            let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber
-        {
+        // Recommended bolus is also not part of the WatchState message.
+        if let recommendedBolus = message[WatchMessageKeys.recommendedBolus] as? NSNumber {
             Task {
                 await WatchLogger.shared.log("⌚️ Received recommended bolus: \(recommendedBolus)")
             }
-
             DispatchQueue.main.async {
                 self.recommendedBolus = recommendedBolus.decimalValue
                 self.showBolusCalculationProgress = false
             }
-
             return
-        } else {
-            Task {
-                await WatchLogger.shared.log("⌚️ Faulty data. Skipping...")
-            }
-            DispatchQueue.main.async {
-                self.showSyncingAnimation = false
-            }
         }
+
+        handleIncomingWatchStatePayload(message)
     }
 
     func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
-        guard let snapshot = WatchStateSnapshot(from: userInfo) else {
-            Task {
-                await WatchLogger.shared.log("⌚️ Invalid snapshot received", force: true)
-            }
+        handleIncomingWatchStatePayload(userInfo)
+    }
+
+    /// Shared path for watch-state payloads from either delegate method.
+    /// Enforces the freshness contract in one place so the two delivery paths
+    /// can't drift.
+    private func handleIncomingWatchStatePayload(_ dictionary: [String: Any]) {
+        guard let payload = dictionary[WatchMessageKeys.watchState] as? [String: Any],
+              let timestamp = payload[WatchMessageKeys.date] as? TimeInterval
+        else {
+            Task { await WatchLogger.shared.log("⌚️ Faulty watch state payload — skipping", force: true) }
+            DispatchQueue.main.async { self.showSyncingAnimation = false }
             return
         }
+        let date = Date(timeIntervalSince1970: timestamp)
 
-        let lastProcessed = WatchStateSnapshot.loadLatestDateFromDisk()
-
-        guard snapshot.date > lastProcessed else {
-            Task {
-                await WatchLogger.shared.log("⌚️ Ignoring outdated or duplicate WatchState snapshot", force: true)
-            }
+        // Wall-clock staleness gate. Drops the queued backlog cheaply when
+        // the watch app wakes after long disuse; without it, every payload
+        // schedules merge + UI work.
+        guard date >= Date().addingTimeInterval(-Self.maxAcceptableMessageAgeInMinutes) else {
+            Task { await WatchLogger.shared.log("⌚️ Skipping stale watch state (\(date))") }
+            DispatchQueue.main.async { self.showSyncingAnimation = false }
             return
         }
 
-        WatchStateSnapshot.saveLatestDateToDisk(snapshot.date)
+        // Monotonicity dedup.
+        let lastProcessed = WatchStateSnapshot.loadLatestDateFromDisk()
+        guard date > lastProcessed else {
+            Task { await WatchLogger.shared.log("⌚️ Skipping duplicate watch state (\(date))") }
+            return
+        }
 
+        WatchStateSnapshot.saveLatestDateToDisk(date)
         DispatchQueue.main.async {
-            self.scheduleUIUpdate(with: snapshot.payload)
+            self.scheduleUIUpdate(with: payload)
         }
     }
 

+ 4 - 23
Trio Watch App Extension/WatchStateSnapshot.swift

@@ -6,34 +6,15 @@
 //
 import Foundation
 
-struct WatchStateSnapshot {
-    let date: Date
-    let payload: [String: Any]
-
-    init?(from dictionary: [String: Any]) {
-        guard let timestamp = dictionary[WatchMessageKeys.date] as? TimeInterval,
-              let payload = dictionary[WatchMessageKeys.watchState] as? [String: Any]
-        else {
-            return nil
-        }
-
-        date = Date(timeIntervalSince1970: timestamp)
-        self.payload = payload
-    }
-
-    func toDictionary() -> [String: Any] {
-        [
-            WatchMessageKeys.date: date.timeIntervalSince1970,
-            WatchMessageKeys.watchState: payload
-        ]
-    }
+enum WatchStateSnapshot {
+    private static let storageKey = "WatchStateSnapshot.latest"
 
     static func saveLatestDateToDisk(_ date: Date) {
-        UserDefaults.standard.set(date.timeIntervalSince1970, forKey: "WatchStateSnapshot.latest")
+        UserDefaults.standard.set(date.timeIntervalSince1970, forKey: storageKey)
     }
 
     static func loadLatestDateFromDisk() -> Date {
-        let interval = UserDefaults.standard.double(forKey: "WatchStateSnapshot.latest")
+        let interval = UserDefaults.standard.double(forKey: storageKey)
         return Date(timeIntervalSince1970: interval)
     }
 }

+ 4 - 23
Trio/Sources/Models/WatchStateSnapshot.swift

@@ -6,34 +6,15 @@
 //
 import Foundation
 
-struct WatchStateSnapshot {
-    let date: Date
-    let payload: [String: Any]
-
-    init?(from dictionary: [String: Any]) {
-        guard let timestamp = dictionary[WatchMessageKeys.date] as? TimeInterval,
-              let payload = dictionary[WatchMessageKeys.watchState] as? [String: Any]
-        else {
-            return nil
-        }
-
-        date = Date(timeIntervalSince1970: timestamp)
-        self.payload = payload
-    }
-
-    func toDictionary() -> [String: Any] {
-        [
-            WatchMessageKeys.date: date.timeIntervalSince1970,
-            WatchMessageKeys.watchState: payload
-        ]
-    }
+enum WatchStateSnapshot {
+    private static let storageKey = "WatchStateSnapshot.latest"
 
     static func saveLatestDateToDisk(_ date: Date) {
-        UserDefaults.standard.set(date.timeIntervalSince1970, forKey: "WatchStateSnapshot.latest")
+        UserDefaults.standard.set(date.timeIntervalSince1970, forKey: storageKey)
     }
 
     static func loadLatestDateFromDisk() -> Date {
-        let interval = UserDefaults.standard.double(forKey: "WatchStateSnapshot.latest")
+        let interval = UserDefaults.standard.double(forKey: storageKey)
         return Date(timeIntervalSince1970: interval)
     }
 }

+ 7 - 8
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -495,12 +495,12 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             return
         }
 
-        // Skip if we already sent this state or older
-        let lastSent = WatchStateSnapshot.loadLatestDateFromDisk()
-        guard lastSent < state.date else {
-            debug(.watchManager, "🕐 Skipping push — newer or equal state already sent")
-            return
-        }
+        // Stamp the snapshot with send time. Each push gets a strictly newer
+        // `date` than the previous one, which is what the watch's monotonicity
+        // dedup relies on — including watch-requested re-pushes when no CGM
+        // tick has bumped the build-time date.
+        var state = state
+        state.date = Date()
 
         let message: [String: Any] = watchStateToDictionary(from: state)
 
@@ -510,12 +510,11 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             session.sendMessage([WatchMessageKeys.watchState: message], replyHandler: nil) { error in
                 debug(.watchManager, "❌ Error sending watch state: \(error)")
             }
-            WatchStateSnapshot.saveLatestDateToDisk(state.date)
         } else {
-            WatchStateSnapshot.saveLatestDateToDisk(state.date)
             session.transferUserInfo([WatchMessageKeys.watchState: message])
             debug(.watchManager, "📤 Transferred new WatchState snapshot via userInfo")
         }
+        WatchStateSnapshot.saveLatestDateToDisk(state.date)
     }
 
     func sendAcknowledgment(toWatch success: Bool, message: String = "", ackCode: AcknowledgmentCode) {